Garbage First收集器

Garbage First 收集器简称G1,是垃圾收集器技术发展史上的里程碑式的成果,

开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。

G1是一款主要面向服务端应用的垃圾收集器,被赋予替代CMS的期望。

JDK9发布后,G1宣告取代了Parallel Scavenge + Parallel Old组合,

成为收集器服务端模式下的默认垃圾收集器,而CMS则被声明为不推荐使用(Deprecate)。

特色

停顿时间模型(Pause Prediction Model):意思是能够支持指定在一个长度为M毫秒的时间片段内,

消耗在垃圾收集器上的时间大概率不超过N毫秒这样的目标。

设计者们希望G1是一款能够建立停顿时间模型的收集器,

以往的收集器垃圾收集的范围要么是整个新生代(Minor GC),要么是整个老年代(Major GC),

要么是整个堆(Full GC)。而G1不是,它可以面向堆内存任何部分来组成回收集(Collection Set,简称CSet

进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,

这就是G1收集器的Mixed GC模式。

基于Region的堆内存布局

G1开创的基于Region的堆内存布局是G1能建立停顿时间模型的关键。

G1也仍然遵循分代收集理论设计,但不再坚持固定大小以及固定数量的分代区域划分,

而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以

根据需要扮演新生代的Eden空间、Survivor空间,或者是老年代空间。

收集器能够对扮演不同角色的Region采用不同的策略去处理。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为大小超过一个Region

容量一半的对象即可判定为大对象。每个Region的大小可以通过-XX:G1HeapRegionSize设定,

取值范围为1MB~32MB,且应为2的N次幂。对于那些超过整个Region容量的超级大对象,将会

存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代

的一部分进行看待。

图片来源链接

能建立可预测的停顿时间模式的原因

是因为G1Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,

这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

具体思路就是让G1收集器跟踪各个Region里边的垃圾堆积的“价值”大小,价值即回收所获得的空间大小

以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间

(使用-XX:MaxGCPauseMillis指定,默认是200毫秒),优先处理回收价值收益最大的那些Region

这也是Garbage First名字的由来。

是有标记过吗,不然怎么知道回收的价值?

其实是有统计数据的,见下边 G1实现难点及方案-> 如何建立可靠的停顿预测模型

G1实现难点及方案

跨代引用问题的解决

Java堆分成多个独立的Region后,Region里边存在跨Region引用对象同样也需要使用记忆集来避免

全堆作为GC Root扫描,G1的记忆集复杂得多,每个Region都维护自己的记忆集,这些记忆集会记录

别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围内。

G1的记忆集在存储结构的本质上是一种哈希表,key是别的Region的起始地址,value是一个集合,

存储的元素是卡表的索引号。这种双向的卡表结构(卡表是“我指向谁”,这种结构还记录了谁指向我)

比原来的卡表实现更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器

要比其他传统垃圾收集器有着更高的内存中用负担。至少要耗费相当于Java堆容量10%至20%的额外内存来维持工作。

每个Region都维护自己的记忆集是怎么理解?

就是每个Region都有自己的记忆集。

并发标记时需要遍历所有Region的记忆集吗?

不用的,要记住G1不是整个新生代or老年代回收的,是选择性地回收Region(回收集),

所以只遍历要回收的回收集的Region的记忆集即可。

之前是只有年轻代建立了卡表,用于标记老年代哪块内存中存在指向新生代的应用。现在是新生代和老年代都有了吗?

是的呀

双向要怎么理解?这里的我是代表啥?是Region吗?

原来的卡表表达的意思就是某个内存中存在跨代引用的指针,即我指向谁。

对于一个Region来说,key是别的Region的起始地址,即谁指向了我(哪个内存中有对象引用了我这个Region中的对象)。

value是卡表的索引值(最终要靠卡表找到卡页,才能找到需要扫描的内存)

并发标记的问题处理

CMS收集器采用增量更新的方式实现,而G1是使用原始快照(SATB)算法来实现。

垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要

继续运行肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS

Top at Mark Start)的指针,把Region的一部分空间划分出来用于并发回收

过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。

啥?

如何建立可靠的停顿预测模型

用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,

但如何才能满足用户的期望呢?G1收集器的停顿预测模式是以衰减均值(Decaying Average

为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个

Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、

置信度等统计信息。

这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体

平均状态,但衰减平均值更准确地代表“最近的”平均状态。也就是Region的统计状态越新,

越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集

才可以在不超过期望停顿时间的约束下获得最高的收益。

G1收集器回收过程

不计算用户线程运行过程中的动作(如写屏障维护记忆集)

  • 初始标记(Initial Markingstw

    仅仅知识标记一下GC Roots能关联到的对象,并修改TAMS指针的值,让下一阶段用户线程并发运行时,

    能正确地在可用Region空间中分配新对象。这个阶段需要停顿线程(根节点枚举都是要STW的),

    但耗时较短,而且是借助进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

  • 并发标记(Concurrent Marking

    GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆的对象图,此阶段耗时较长,但是并发的。

    扫描完成后,重新处理SATB记录下的在并发时有引用变动的对象。(原始快照)

  • 最终标记(Final Markingstw

    对用户线程做另一个短暂停顿,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

  • 筛选回收(Live Data Counting and Evacuationstw

    负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期盼的停顿时间来制定

    回收计划,可以自由选择多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的

    Region中,在清理掉整个旧的Region的全部空间。这里的操作设计存活对象的移动,是必须暂停用户线程的,

    由多条收集器线程并行完成。

前三个步骤还蛮像CMS的,CMS第四个是并发清理。

G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,并非纯粹地追求低延迟,

官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量。

可以由用户指定期望停顿时间是G1收集器很强大的一个功能。默认的停顿时间为两百毫秒,

一般来说回收阶段占几十到一百甚至接近两百毫秒都很正常。

G1 与 CMS

相比CMSG1的优点有很多
  • 可以指定最大停顿时间

  • Region的内存布局、按照收益动态确定回收集带来的好处

  • G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(Region之间)又是“标记-复制”算法。

    意味着G1运作期间不会产生内存空间碎片。

CMS的胜出点
  • G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload

    都要比CMS要高。

  • G1CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且无论扮演的是新生代还是老年代,

    都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能占整个堆容量20%乃至更多内存空间;

    CMS的卡表相当简单,只有一份,而且只需要处理老年代到新生代的引用,反过来不需要。(代价就是当

    CMS发生Old GC时,要把整个新生代作为GC Roots来进行扫描)

选择

在小内存应用上CMS的表现大概率要优于G1,而在大内存应用上G1则能发挥其优势。

这个优劣势的Java堆容量平衡点通常在6GB至8GB